require 'spec_helper'

RSpec.describe Msf::Exploit::Remote::HTTP::HttpCookieJar do
  def random_string(min_len = 1, max_len = 12)
    str = Faker::Alphanumeric.alpha(number: max_len)
    str[0, rand(min_len..max_len)]
  end

  def random_cookie
    Msf::Exploit::Remote::HTTP::HttpCookie.new(
      random_string,
      random_string,
      max_age: Faker::Number.within(range: 1..100),
      path: '/' + random_string,
      secure: Faker::Boolean.boolean,
      httponly: Faker::Boolean.boolean,
      domain: random_string
    )
  end

  def random_origin
    domain = random_string(4,10)
    path = "/#{random_string(4,10)}/"
    url = "http://#{domain}#{path}"

    {
      'domain' => domain,
      'path' => path,
      'url' => url
    }
  end

  let(:cookie_jar) { described_class.new }

  before(:each) do
    Timecop.freeze(Time.local(2008, 9, 5, 10, 5, 30))
  end

  after(:each) do
    Timecop.return
  end

  describe '#empty?' do
    it 'will return true when no cookies are in a cookie_jar' do
      # cookie_jar made in before

      e = cookie_jar.empty?

      expect(e).to eq(true)
    end

    it 'will return false when a cookie has been added to a cookie_jar' do
      c = random_cookie

      cookie_jar.add(c)
      e = cookie_jar.empty?

      expect(e).to eq(false)
    end
  end

  describe '#clear' do
    it 'will make no changes to an empty cookiejar' do
      # empty cookie_jar made in before

      cookie_jar.clear

      expect(cookie_jar.empty?).to eq(true)
    end

    it 'will return true when populated cookie_jar has been cleared' do
      c = random_cookie

      cookie_jar.add(c)
      cookie_jar.clear

      expect(cookie_jar.empty?).to eq(true)
    end
  end

  describe '#cookies' do
    it 'will return an empty array when no cookies have been added to the jar' do
      # cookie_jar made in before

      c_array = cookie_jar.cookies

      expect(c_array.class).to eq(Array)
      expect(c_array.empty?).to eq(true)
    end

    it 'will return an array of all cookies added to the cookie_jar when called with no url param' do
      c_array = []
      Faker::Number.within(range: 1..10).times do
        c = random_cookie

        c_array.append(c)
        cookie_jar.add(c)
      end

      jar_array = cookie_jar.cookies

      expect(c_array.sort).to eq(jar_array.sort)
    end
  end

  describe '#add' do
    it 'unacceptable cookie without a domain will throw an ArgumentError' do
      c = Msf::Exploit::Remote::HTTP::HttpCookie.new(
        random_string,
        random_string,
        path: '/' + random_string
      )

      expect do
        cookie_jar.add(c)
      end.to raise_error(ArgumentError)
    end

    it 'unacceptable cookie without a path will throw an ArgumentError' do
      c = Msf::Exploit::Remote::HTTP::HttpCookie.new(
        random_string,
        random_string,
        domain: random_string
      )

      expect do
        cookie_jar.add(c)
      end.to raise_error(ArgumentError)
    end

    it 'acceptable cookie added to cookie_jar successfully' do
      c = random_cookie

      cookie_jar.add(c)

      expect(cookie_jar.cookies[0] == c)
    end

    it 'acceptable cookie added to cookie_jar containing cookie with the same name, domain, and path will result in an overwrite' do
      c = random_cookie
      c_dup = random_cookie
      c_dup.name = c.name
      c_dup.domain = c.domain
      c_dup.path = c.path

      cookie_jar.add(c)
      cookie_jar.add(c_dup)

      expect(cookie_jar.cookies).to match_array([c_dup])
    end

    it 'variable not a subclass of ::HttpCookie will raise TypeError' do
      int = 1

      expect do
        cookie_jar.add(int)
      end.to raise_error(TypeError)
    end
  end

  describe '#delete' do
    it 'used on an empty jar will return nil' do
      # cookie_jar made in before

      n = cookie_jar.delete(random_cookie)

      expect(n).to eq(nil)
    end

    it 'passed cookie with same name, domain, and path as cookie in jar, will delete cookie in jar' do
      c = random_cookie
      c_dup = random_cookie
      c_dup.name = c.name
      c_dup.domain = c.domain
      c_dup.path = c.path

      cookie_jar.add(c)
      cookie_jar.delete(c_dup)

      expect(cookie_jar.empty?).to eq(true)
    end

    it 'passed a cookie different name, domain, and path as cookie in jar, will not delete cookie in jar' do
      c = random_cookie
      c_dup = random_cookie
      c_dup.name = c.name + random_string(1, 1)
      c_dup.domain = c.domain + random_string(1, 1)
      c_dup.path = c.path + random_string(1, 1)

      cookie_jar.add(c)
      cookie_jar.delete(c_dup)

      expect(cookie_jar.cookies.length).to eq(1)
      expect(cookie_jar.cookies[0]).to eql(c)
    end

    it 'variable not a subclass of ::HttpCookie will not raise TypeError when the cookie_jar is empty' do
      int = Faker::Number.within(range: 1..100)

      n = cookie_jar.delete(int)

      expect(n).to eq(nil)
      expect(cookie_jar.empty?).to eq(true)
    end

    it 'variable not a subclass of ::HttpCookie will raise TypeError when the cookie_jar is not empty' do
      cookie_jar.add(random_cookie)
      int = Faker::Number.within(range: 1..100)

      expect do
        cookie_jar.delete(int)
      end.to raise_error(TypeError)
    end
  end

  describe '#cleanup' do
    it 'will make no changes to an empty cookiejar' do
      # empty cookie_jar made in before

      cookie_jar.cleanup

      expect(cookie_jar.empty?).to eq(true)
    end

    it 'will remove expired cookies with max_age value' do
      expired_cookies = [random_cookie, random_cookie]
      expired_cookies[0].max_age = 1
      expired_cookies[1].max_age = 1
      expired_cookies[0].created_at = Time.local(2008, 9, 5, 10, 5, 1)
      expired_cookies[1].created_at = Time.local(2008, 9, 5, 10, 5, 1)
      cookies = [random_cookie, random_cookie]
      cookies[0].max_age = 10000
      cookies[1].max_age = 10000
      cookies[0].created_at = Time.now
      cookies[1].created_at = Time.now

      cookies.each { |c| cookie_jar.add(c) }
      expired_cookies.each { |c| cookie_jar.add(c) }
      cookie_jar.cleanup

      expect(cookie_jar.cookies).to match_array(cookies)
    end

    it 'will remove expired cookies with expires value' do
      expired_cookies = [random_cookie, random_cookie]
      expired_cookies[0].expires = Time.local(2008, 9, 3, 10, 5, 0)
      expired_cookies[1].expires = Time.local(2008, 9, 4, 10, 5, 0)
      cookies = [random_cookie, random_cookie]
      cookies[0].expires = Time.local(2008, 9, 6, 10, 5, 0)
      cookies[1].expires = Time.local(2008, 9, 7, 10, 5, 0)

      cookies.each { |c| cookie_jar.add(c) }
      expired_cookies.each { |c| cookie_jar.add(c) }
      cookie_jar.cleanup

      expect(cookie_jar.cookies).to match_array(cookies)
    end
  end

  describe '#dup' do
    it 'ensures the cookie jar is additionally duped' do
      original = cookie_jar
      dup = cookie_jar.dup

      5.times { original.add(random_cookie) }
      2.times { dup.add(random_cookie) }

      expect(original.cookies.length).to be 5
      expect(dup.cookies.length).to be 2
    end
  end

  describe '#parse' do
    it 'silently ignores incorrectly built headers' do
      header = ";;'//;;'"
      origin = random_origin

      cookies = cookie_jar.parse(header, origin['url'])

      expect(cookies).to eq([])
    end

    it 'silently ignores name only cookie headers' do
      header = "#{random_string(4,10)}"
      origin = random_origin

      cookies = cookie_jar.parse(header, origin['url'])

      expect(cookies).to eq([])
    end

    it 'correctly parses cookie headers with name and value only' do
      cookie_name = random_string(4,10)
      cookie_value = random_string(4,10)
      header = "#{cookie_name}=#{cookie_value};"
      origin = random_origin

      cookie = Msf::Exploit::Remote::HTTP::HttpCookie.new(
        cookie_name,
        cookie_value,
        domain: origin['domain'],
        path: origin['path'],
        origin: origin['url']
      )
      header_cookie = cookie_jar.parse(header, origin['url']).first

      expect(cookie).to eq(header_cookie)
    end

    it 'does not overwrite header domain values with a passed origin' do
      cookie_domain = random_string(11,20)
      header = "#{random_string(4,10)}=#{random_string(4,10)}; domain=#{cookie_domain}"
      origin = random_origin

      header_cookie = cookie_jar.parse(header, origin['url']).first

      expect(header_cookie.domain).to eq(cookie_domain)
      expect(header_cookie.domain).not_to eq(origin['domain'])
    end

    it 'does not overwrite header path values with a passed origin' do
      cookie_path = "/#{random_string(11,20)}/"
      header = "#{random_string(4,10)}=#{random_string(4,10)}; path=#{cookie_path}"
      origin = random_origin

      header_cookie = cookie_jar.parse(header, origin['url']).first

      expect(header_cookie.path).to eq(cookie_path)
      expect(header_cookie.path).not_to eq(origin['path'])
    end

    it 'sets max-age correctly' do
      max_age = rand(100)
      header = "#{random_string(4,10)}=#{random_string(4,10)}; max-age=#{max_age}"
      origin = random_origin

      header_cookie = cookie_jar.parse(header, origin['url']).first

      expect(header_cookie.max_age).to eq(max_age)
    end

    it 'sets expires correctly' do
      expires = Time.now.utc # Underlying +::HTTP::Cookie::Scanner#scan_set_cookie+ ignores timezones, instead storing expires values as UTC
      time_str = expires.strftime '%Y-%b-%d %H:%M:%S' # Acceptable expires format as described in RFC 6265 5.1.1
      header = "#{random_string(4,10)}=#{random_string(4,10)}; expires=#{time_str}"
      origin = random_origin

      header_cookie = cookie_jar.parse(header, origin['url']).first

      expect(header_cookie.expires).to eq(expires)
    end

    it 'sets secure to false when not included in the header' do
      header = "#{random_string(4,10)}=#{random_string(4,10)};"
      origin = random_origin

      header_cookie = cookie_jar.parse(header, origin['url']).first

      expect(header_cookie.secure).to eq(false)
    end

    it 'sets secure to true when included in the header' do
      header = "#{random_string(4,10)}=#{random_string(4,10)}; secure;"
      origin = random_origin

      header_cookie = cookie_jar.parse(header, origin['url']).first

      expect(header_cookie.secure).to eq(true)
    end

    it 'sets httponly to false when not included in the header' do
      header = "#{random_string(4,10)}=#{random_string(4,10)};"
      origin = random_origin

      header_cookie = cookie_jar.parse(header, origin['url']).first

      expect(header_cookie.httponly).to eq(false)
    end

    it 'sets httponly to true when included in the header' do
      header = "#{random_string(4,10)}=#{random_string(4,10)}; httponly;"
      origin = random_origin

      header_cookie = cookie_jar.parse(header, origin['url']).first

      expect(header_cookie.httponly).to eq(true)
    end

    it 'depends on scan_set_cookie returning a hash' do
      header = 'cookie-one=value-one;, cookie-two=value-two;'
      origin = random_origin['url']

      allow_any_instance_of(::HTTP::Cookie::Scanner).to receive(:scan_set_cookie).and_yield 'name', 'val', nil

      expect do
        cookie_jar.parse(header, origin)
      end.to raise_error(ArgumentError)
    end
  end

  describe '#parse_and_merge' do
    it 'will merge valid cookie headers' do
      cookie_name = random_string(4,10)
      cookie_value = random_string(4,10)
      header = "#{cookie_name}=#{cookie_value};"
      origin = random_origin

      cookie = Msf::Exploit::Remote::HTTP::HttpCookie.new(
        cookie_name,
        cookie_value,
        domain: origin['domain'],
        path: origin['path'],
        origin: origin['url']
      )
      cookie_jar.parse_and_merge(header, origin['url']).first

      expect(cookie_jar.cookies).to eq([cookie])
    end

    it 'will not merge invalid cookie headers' do
      header = ";;;;|||;;;"
      origin = random_origin

      cookie_jar.parse_and_merge(header, origin['url']).first

      expect(cookie_jar.cookies).to eq([])
    end
  end
end
